[READ-ONLY] a fast, modern browser for the npm registry
at main 152 lines 5.1 kB view raw
1import { getQuery } from 'h3' 2import * as v from 'valibot' 3import { hash } from 'ohash' 4import type { VersionDistributionResponse } from '#shared/types' 5import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' 6import { groupVersionDownloads } from '#server/utils/version-downloads' 7 8/** 9 * Raw response from npm downloads API 10 * GET https://api.npmjs.org/versions/{package}/last-week 11 */ 12interface NpmVersionDownloadsResponse { 13 package: string 14 downloads: Record<string, number> 15} 16 17/** 18 * Query parameter validation schema 19 */ 20const QuerySchema = v.object({ 21 mode: v.optional(v.picklist(['major', 'minor'] as const), 'major'), 22 filterThreshold: v.optional( 23 v.pipe( 24 v.string(), 25 v.toNumber(), // Fails validation on invalid conversion (e.g., "abc") instead of producing NaN 26 v.minValue(0), // Ensure non-negative values 27 ), 28 ), 29 filterOldVersions: v.optional(v.picklist(['true', 'false'] as const), 'false'), 30}) 31 32/** 33 * GET /api/registry/downloads/:name/versions or /api/registry/downloads/@scope/name/versions 34 * 35 * Fetch per-version download statistics and group by major or minor version. 36 * Data is cached for 1 hour with stale-while-revalidate. 37 * 38 * Query parameters: 39 * - mode: 'major' | 'minor' (default: 'major') 40 * - filterThreshold: minimum percentage to include (default: 1) 41 * - filterOldVersions: 'true' to include only versions published in last year (default: 'false') 42 */ 43export default defineCachedEventHandler( 44 async event => { 45 // Supports: /downloads/lodash/versions, /downloads/@scope/name/versions 46 const slugParam = getRouterParam(event, 'slug') 47 const pkgParamSegments = slugParam?.split('/') ?? [] 48 49 const lastSegment = pkgParamSegments.at(-1) 50 if (!lastSegment || lastSegment !== 'versions') { 51 throw createError({ 52 statusCode: 404, 53 message: 'Invalid endpoint. Expected /versions', 54 }) 55 } 56 57 const segments = pkgParamSegments.slice(0, -1) 58 59 const { rawPackageName } = parsePackageParams(segments) 60 61 if (!rawPackageName) { 62 throw createError({ 63 statusCode: 404, 64 message: 'Package name is required', 65 }) 66 } 67 68 try { 69 const query = getQuery(event) 70 const parsed = v.parse(QuerySchema, query) 71 const mode = parsed.mode 72 const filterThreshold = parsed.filterThreshold ?? 1 73 const filterOldVersionsBool = parsed.filterOldVersions === 'true' 74 75 const url = `https://api.npmjs.org/versions/${rawPackageName}/last-week` 76 const npmResponse = await fetch(url) 77 78 if (!npmResponse.ok) { 79 if (npmResponse.status === 404) { 80 throw createError({ 81 statusCode: 404, 82 message: 'Package not found', 83 }) 84 } 85 throw createError({ 86 statusCode: 502, 87 message: 'Failed to fetch version download data from npm API', 88 }) 89 } 90 91 const data: NpmVersionDownloadsResponse = await npmResponse.json() 92 93 let groups = groupVersionDownloads(data.downloads, mode) 94 95 if (filterThreshold > 0) { 96 groups = groups.filter(group => group.percentage >= filterThreshold) 97 } 98 99 const totalDownloads = Object.values(data.downloads).reduce((sum, count) => sum + count, 0) 100 101 const apiResponse: VersionDistributionResponse = { 102 package: rawPackageName, 103 mode, 104 totalDownloads, 105 groups, 106 timestamp: new Date().toISOString(), 107 } 108 109 if (filterOldVersionsBool) { 110 try { 111 const oneYearAgo = new Date() 112 oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) 113 const afterDate = oneYearAgo.toISOString() 114 115 // Decode package name in case it's URL-encoded (e.g., %40prisma%2Fclient -> @prisma/client) 116 const decodedPackageName = decodeURIComponent(rawPackageName) 117 118 // Fetch directly from npm-fast-meta HTTP API 119 const fastMetaUrl = `https://npm.antfu.dev/versions/${encodeURIComponent(decodedPackageName)}?after=${encodeURIComponent(afterDate)}` 120 const fastMetaResponse = await fetch(fastMetaUrl) 121 122 if (!fastMetaResponse.ok) { 123 throw new Error(`npm-fast-meta returned ${fastMetaResponse.status}`) 124 } 125 126 const versionData = (await fastMetaResponse.json()) as { versions: string[] } 127 apiResponse.recentVersions = versionData.versions 128 } catch { 129 // Graceful degradation - don't fail entire request if npm-fast-meta fails 130 } 131 } 132 133 return apiResponse 134 } catch (error: unknown) { 135 handleApiError(error, { 136 statusCode: 502, 137 message: 'Failed to fetch version download distribution', 138 }) 139 } 140 }, 141 { 142 maxAge: CACHE_MAX_AGE_ONE_HOUR, 143 swr: true, 144 getKey: event => { 145 const slug = getRouterParam(event, 'slug') ?? '' 146 const query = getQuery(event) 147 // Use ohash to create deterministic cache key from query params 148 // This ensures different param combinations = different cache entries 149 return `version-downloads:v5:${slug}:${hash(query)}` 150 }, 151 }, 152)